feat(tron): add tx signing, fee estimation, and withdrawals#2714
feat(tron): add tx signing, fee estimation, and withdrawals#2714shamardy wants to merge 9 commits intotron-tokens-activationfrom
Conversation
Hand-written prost::Message structs for the minimal TRON transaction protobuf types needed for TRX transfers and TRC20 interactions: TransferContract, TriggerSmartContract, TransactionContract, TransactionRaw (non-sequential tags), and Transaction. Includes golden vector tests using real raw_data_hex from TRON developer docs to validate wire-format compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Parse `blockID` (hex → [u8; 32]) and `timestamp` from the `/wallet/getnowblock` response. Add `TaposBlockData` struct and `get_block_for_tapos()` on both `TronHttpClient` and `TronApiClient` (with node rotation) to provide validated inputs for TRON transaction building. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…fers Build TransactionRaw protobuf messages for native TRX transfers and TRC20 token transfers. Reuses the shared ERC20_CONTRACT ABI for transfer(address,uint256) encoding, matching the EVM code path. Golden vector tests verify byte-exact output against real Nile testnet transactions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add TRON fee estimation module with bandwidth/energy cost calculations and chain prices API. Extract TxFeeDetails from lp_coins.rs into its own module for better organization. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add two TRON API primitives needed for fee estimation and transaction
broadcasting:
- get_account_resource(address): fetches bandwidth/energy quotas from
/wallet/getaccountresource, mapping TRON's mixed-case proto3 JSON
fields into the existing TronAccountResources domain type via an
intermediate serde struct with exact #[serde(rename)] per field.
- broadcast_hex(tx_hex): submits signed protobuf bytes via
/wallet/broadcasthex, returning BroadcastHexResponse { txid }.
Error responses (result: false) handled by existing
tron_error_from_value() in post().
Both methods available on TronHttpClient and TronApiClient (with
try_clients node rotation).
Also standardizes all request structs to visible: true (Base58
addresses) for consistency, and removes a duplicated
TriggerConstantContractRequest from integration tests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dispatch EthCoin::send_raw_tx() and send_raw_tx_bytes() by ChainFamily: - EVM: keeps existing eth_sendRawTransaction path - TRON: validates input then routes to TronApiClient::broadcast_hex() Input validation for TRON: strip 0x prefix, hex-only chars, even length, 256 KiB size cap. Empty payloads rejected on both string and bytes paths. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement the full TRON withdrawal flow integrated into EthWithdraw: - TronWithdrawContext groups shared parameters for both transfer types - build_tron_trx_withdraw: fee estimation with convergence loop for max-withdraw (handles varint-encoded amount affecting tx size/fee) - build_tron_trc20_withdraw: energy estimation, fee_limit, and TRX balance check for fee coverage - validate_tron_fee_policy: rejects EVM gas options for TRON - build_tron_withdraw in EthWithdraw: orchestrates TRON-specific RPC calls, signing (protobuf), and TransactionDetails assembly - Refactor build_transaction_details into shared method used by both EVM and TRON paths, eliminating duplicated spent/received logic Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…and doc fixes - Add 7 withdraw integration tests against Nile testnet (TRX/TRC20, iguana/HD, max, insufficient balance, fee structure validation) - Add pre-withdraw balance checks via my_balance RPC - Use exact vec equality for from/to address assertions (matching ETH/Tendermint patterns) - Assert spent_by_me, received_by_me, my_balance_change exactly (TRX: amount+fee, TRC20: amount only since fee is in TRX) - Add TronApiClient-based on-chain verification helper with node failover - Convert 24 deterministic unit tests to cross_test! for WASM compat - Add module-level and struct docs for fee.rs and tx_fee_details.rs - Add second Nile testnet node (api.nileex.io) for failover - Feature-gate TRON_API_TIMEOUT: 10s production, 60s tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
mariocynicys
left a comment
There was a problem hiding this comment.
Thanks!
First review iteration. Will need another one. Skipping most of the tests for now.
| //! Hand-written `prost::Message` structs matching TRON's `Tron.proto`, | ||
| //! `balance_contract.proto`, and `smart_contract.proto` definitions. |
There was a problem hiding this comment.
why don't we include these files in our codebase instead and generate the rust structs from them?
| /// Transaction creation time in milliseconds since epoch. | ||
| #[prost(int64, tag = "14")] | ||
| pub timestamp: i64, |
| /// Response from `/wallet/getnowblock`. | ||
| #[derive(Deserialize, Debug)] | ||
| pub struct GetNowBlockResponse { | ||
| /// Computed block identifier (not in protobuf — added by the HTTP servlet layer). | ||
| /// First 8 bytes duplicate the block number (big-endian) for sortability; remaining 24 bytes | ||
| /// are from SHA256 of `block_header.raw_data`. We only need bytes `[8..16]` for TAPOS | ||
| /// (`ref_block_hash`); the block number itself comes from `block_header.raw_data.number`. | ||
| /// Deserialized from a 64-char hex string; `None` if absent. | ||
| /// See [`generateBlockId`](https://github.com/tronprotocol/java-tron/blob/1e35f79/common/src/main/java/org/tron/common/utils/Sha256Hash.java#L252-L258). | ||
| #[serde(default, rename = "blockID", deserialize_with = "deserialize_opt_block_id")] | ||
| pub block_id: Option<[u8; 32]>, | ||
| /// Block header containing raw block data (number, timestamp, etc.). | ||
| #[serde(default)] | ||
| pub block_header: Option<BlockHeader>, | ||
| } |
There was a problem hiding this comment.
Q: since the two fields inside are optional, in what cases would they be None? and can one of them be Some while the other isn't?
| /// Block timestamp in milliseconds since epoch. | ||
| #[serde(default)] | ||
| pub timestamp: i64, |
There was a problem hiding this comment.
same as the other comment in proto.rs: what is since epoch? since the start of the current epoch i presume?
| /// Non-hex `blockID` must fail deserialization (triggers `BadResponse` → node rotation). | ||
| #[test] | ||
| fn parse_getnowblock_rejects_invalid_block_id_hex() { | ||
| let json = r#"{ "blockID": "not_valid_hex!!", "block_header": { "raw_data": { "number": 1 } } }"#; | ||
| assert!(serde_json::from_str::<GetNowBlockResponse>(json).is_err()); | ||
| } | ||
|
|
||
| /// `blockID` that isn't exactly 32 bytes must fail deserialization. | ||
| #[test] | ||
| fn parse_getnowblock_rejects_wrong_length_block_id() { | ||
| // 31 bytes (62 hex chars) — too short | ||
| let json = r#"{ "blockID": "00000000033bab42e37d025dc14e9ebc26e8f6cb6b6e26e08d2bf2db29c3b4", "block_header": { "raw_data": { "number": 1 } } }"#; | ||
| assert!(serde_json::from_str::<GetNowBlockResponse>(json).is_err()); | ||
| } |
There was a problem hiding this comment.
let's be more strict and not just check is_err(). let's check the error structure/variant.
| let timestamp = header.raw_data.timestamp; | ||
| if timestamp <= 0 { | ||
| return Err( | ||
| Web3RpcError::BadResponse(format!("TRON node returned invalid block timestamp: {timestamp}")).into(), | ||
| ); | ||
| } |
There was a problem hiding this comment.
why not also verify the timestamp in validated_header() like what we did with the block number.
| //! Free functions that build, estimate fees for, and prepare TRON withdrawal | ||
| //! transactions (TRX native and TRC20 token). Signing and `TransactionDetails` |
There was a problem hiding this comment.
what does Free mean here?
| // amount), so changing the amount can change the fee. The `>=` (not `==`) | ||
| // break prevents infinite oscillation at varint boundaries, where reducing |
There was a problem hiding this comment.
i don't think we can even use ==. this will break for non-max withdraws.
| // the amount lowers the fee but increasing it raises the fee back. This may | ||
| // leave up to 1 bandwidth byte of dust (~1000 SUN) in that rare edge case. |
There was a problem hiding this comment.
i think based on the structure of the logic here, this will always happen.
if the request is setting max: true, we can never match affordable >= amount_sun from the first try, so we need at least two iterations.
the critical question is:
is build_trx_transfer() always guaranteed to return a smaller-sized tx when the amount is decreased? can't the encoding for a smaller number be bigger in size than a bigger number?
edit: yup, protobuf's varint docs say that.
| /// Build TRC20 withdraw: estimate energy + bandwidth fees, return final tx raw. | ||
| pub async fn build_tron_trc20_withdraw( | ||
| ctx: &TronWithdrawContext<'_>, | ||
| tron: &TronApiClient, | ||
| contract_tron: &TronAddress, | ||
| amount_base_units: U256, | ||
| ) -> Result<(TransactionRaw, TronTxFeeDetails, U256), MmError<WithdrawError>> { |
There was a problem hiding this comment.
why no max support for trc20?
Summary
Add transaction signing, fee estimation, and the full withdraw pipeline for TRX native and TRC20 token transfers. This is the fourth PR in the TRON integration series:
Key Features
prost::Messagederive macros — no dependency on node-side transaction creationr||s||vsignatures withv ∈ {0,1}TxFeeDetails::Tronvariant — reportsbandwidth_used,energy_used,bandwidth_fee,energy_fee,total_feein withdraw responsessend_raw_tx— TRON protobuf hex routes to/wallet/broadcasthex, EVM RLP routes toeth_sendRawTransactionArchitecture
New modules under
mm2src/coins/eth/tron/:proto.rsTransaction,TransactionRaw,TransferContract,TriggerSmartContract) with correct non-sequential field tagstx_builder.rstransfer(address,uint256)calls, with TAPOS reference block computationsign.rssign_tron_transaction()— SHA256 digest → secp256k1 sign → 65-byte signature with recovery id normalizationfee.rstriggerconstantcontract, fee calculation with resource deficit logicwithdraw.rsbuild_tron_trx_withdraw()with iterative max-send convergence,build_tron_trc20_withdraw()with cross-asset TRX balance verificationModified files:
eth/tron/api.rsget_account_resource(),broadcast_hex(), extendedgetnowblockfor TAPOS block dataeth/eth_withdraw.rseth.rssend_raw_txchain dispatch (TRON →broadcast_hex, EVM →eth_sendRawTransaction)lp_coins.rs/tx_fee_details.rsTxFeeDetails::TronvariantTest Coverage
7 withdraw integration tests (
cargo test --test mm2_tests_main --features tron-network-tests -- tron):test_trx_withdraw_and_sendspent_by_me/my_balance_changeassertionstest_trc20_withdraw_and_sendtest_trx_withdraw_maxtotal_amount + fee ≈ balancetest_trx_withdraw_hdtest_trc20_withdraw_hdtest_trx_withdraw_insufficient_balancetest_trx_fee_details_structureTxFeeDetails::Tronfields present with correct types and valuesCoin Configuration (unchanged from #2712)
{ "coin": "TRX", "name": "tron", "fname": "TRON", "mm2": 1, "wallet_only": true, "chain_id": 728126428, "protocol": { "type": "TRON" } }TRON Roadmap (Next Steps)
This PR completes wallet-only TRON support. The following items remain for full TRON parity with EVM chains:
SwapOps,MakerCoinSwapOpsV2,TakerCoinSwapOpsV2), order matching integration, and trade fee methodsdevand cut a release with TRX/TRC20 swap supportPartially addresses #1542 and #1698.
🤖 Generated with Claude Code